package com.bugsnag.ohos;

import ohos.app.Context;

import com.google.gson.stream.JsonReader;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.channels.OverlappingFileLockException;
import java.util.UUID;

/**
 * This class is responsible for persisting and retrieving the device ID which uniquely
 * identifies this device.
 * <p>
 * This class is made multi-process safe through the use of a [FileLock], and thread safe
 * through the use of a [ReadWriteLock] in [SynchronizedStreamableStore].
 *
 * @since 2021-07-05
 */
public class DeviceIdStore {
    private static final int MAX_FILE_LOCK_ATTEMPTS = 20;
    private static final long FILE_LOCK_WAIT_MS = 25L;
    private Context context;
    private File file;
    private SharedPrefMigrator sharedPrefMigrator;
    private Logger logger;
    private SynchronizedStreamableStore<DeviceId> synchronizedStreamableStore;

    /**
     * constructor
     *
     * @param context
     * @param sharedPrefMigrator
     * @param logger
     */
    public DeviceIdStore(Context context, SharedPrefMigrator sharedPrefMigrator, Logger logger) {
        this(new File(context.getFilesDir(), "device-id"), sharedPrefMigrator, logger);
        this.context = context;
    }

    /**
     * constructor
     *
     * @param file
     * @param sharedPrefMigrator
     * @param logger
     */
    public DeviceIdStore(File file, SharedPrefMigrator sharedPrefMigrator, Logger logger) {
        this.file = file;
        this.sharedPrefMigrator = sharedPrefMigrator;
        this.logger = logger;

        try {
            if (!file.exists()) {
                file.createNewFile();
            }
        } catch (IOException exc) {
            logger.w("Failed to created device ID file", exc);
        }
        this.synchronizedStreamableStore = new SynchronizedStreamableStore(file);
    }

    /**
     * constructor
     *
     * @param context
     * @param file
     * @param sharedPrefMigrator
     * @param logger
     */
    public DeviceIdStore(Context context, File file, SharedPrefMigrator sharedPrefMigrator, Logger logger) {
        this.context = context;
        this.file = file;
        this.sharedPrefMigrator = sharedPrefMigrator;
        this.logger = logger;

        try {
            if (!file.exists()) {
                file.createNewFile();
            }
        } catch (IOException exc) {
            logger.w("Failed to created device ID file", exc);
        }
        this.synchronizedStreamableStore = new SynchronizedStreamableStore(file);
    }

    /**
     * Loads the device ID from its file system location. Device IDs are UUIDs which are
     * persisted on a per-install basis. This method is thread-safe and multi-process safe.
     * <p>
     * If no device ID exists then the legacy value stored in [SharedPreferences] will
     * be used. If no value is present then a random UUID will be generated and persisted.
     *
     * @return device id
     */
    public String loadDeviceId() {
        String legacyDeviceId = sharedPrefMigrator.loadDeviceId();
        if (legacyDeviceId == null) {
            return loadDeviceId(UUID.randomUUID());
        } else {
            return loadDeviceId(UUID.fromString(legacyDeviceId));
        }
    }

    /**
     * load device id
     *
     * @param uuidProvider
     * @return device id
     */
    public String loadDeviceId(UUID uuidProvider) {
        DeviceId deviceId = loadDeviceIdInternal();
        if (deviceId != null) {
            if (deviceId.id != null) {
                return deviceId.id;
            } else {
                return persistNewDeviceUuid(uuidProvider);
            }
        } else {
            return persistNewDeviceUuid(uuidProvider);
        }
    }

    /**
     * Loads the device ID from the file.
     * <p>
     * If the file has zero length it can't contain device ID, so reading will be skipped.
     */
    private DeviceId loadDeviceIdInternal() {
        if (file.length() > 0) {
            try {
                return synchronizedStreamableStore.load(jsonReader -> {
                    DeviceId deviceId = new DeviceId();
                    return deviceId.fromReader(jsonReader);
                });
            } catch (FileNotFoundException exc) {
                logger.w("Failed to load device ID", exc);
            } catch (UnsupportedEncodingException exc) {
                logger.w("Failed to load device ID", exc);
            }
        }
        return null;
    }

    /**
     * Write a new Device ID to the file.
     *
     * @return device uuid
     */
    private String persistNewDeviceUuid(UUID uuidProvider) {
        try {
            FileOutputStream fileOutputStream = new FileOutputStream(file);
            FileChannel channel = fileOutputStream.getChannel();
            return persistNewDeviceIdWithLock(channel, uuidProvider);
        } catch (IOException exc) {
            logger.w("Failed to persist device ID", exc);
            return null;
        }
    }

    private String persistNewDeviceIdWithLock(FileChannel channel, UUID uuidProvider) {
        FileLock lock = waitForFileLock(channel);
        if (lock == null) {
            return null;
        }

        try {
            // read the device ID again as it could have changed
            // between the last read and when the lock was acquired
            DeviceId deviceId = loadDeviceIdInternal();

            if (deviceId != null && deviceId.id != null) {
                // the device ID changed between the last read
                // and acquiring the lock, so return the generated value
                return deviceId.id;
            } else {
                // generate a new device ID and persist it
                DeviceId newId = new DeviceId(uuidProvider.toString());
                synchronizedStreamableStore.persist(newId);
                return newId.id;
            }
        } catch (IOException e) {
            e.getMessage();
        } finally {
            try {
                lock.release();
            } catch (IOException e) {
                e.getMessage();
            }
        }
        return null;
    }

    /**
     * Attempt to acquire a file lock. If [OverlappingFileLockException] is thrown
     * then the method will wait for 50ms then try again, for a maximum of 10 attempts.
     */
    private FileLock waitForFileLock(FileChannel channel) {
        for (int i = 0; i < MAX_FILE_LOCK_ATTEMPTS; i++) {
            try {
                return channel.tryLock();
            } catch (OverlappingFileLockException exc) {
                try {
                    java.lang.Thread.sleep(FILE_LOCK_WAIT_MS);
                } catch (InterruptedException e) {
                    e.getMessage();
                }
            } catch (IOException e) {
                e.getMessage();
            }
        }
        return null;
    }

    /**
     * DeviceId
     *
     * @since 2021-07-27
     */
    private class DeviceId implements JsonStream.Streamable, JsonReadable<DeviceId> {
        private static final String KEY_ID = "id";
        private String id;

        DeviceId() {
        }

        DeviceId(String id) {
            this.id = id;
        }

        @Override
        public void toStream(JsonStream stream) throws IOException {
            stream.beginObject();
            stream.name(KEY_ID);
            stream.value(id);
            stream.endObject();
        }

        @Override
        public DeviceId fromReader(JsonReader reader) {
            String deviceId = null;
            try {
                reader.beginObject();
                if (reader.hasNext() && reader.nextName() == KEY_ID) {
                    deviceId = reader.nextString();
                }
            } catch (IOException e) {
                e.getMessage();
            }
            return new DeviceId(deviceId);
        }
    }
}